Skip to content

feat: adopt wave into the language, with a generative harmonics form - #16

Merged
spacedevin merged 2 commits into
mainfrom
feat/wave-core-harmonics
Aug 27, 2026
Merged

feat: adopt wave into the language, with a generative harmonics form#16
spacedevin merged 2 commits into
mainfrom
feat/wave-core-harmonics

Conversation

@spacedevin

Copy link
Copy Markdown
Owner

wave <name> <32 hex nibbles> was never part of the grammar. It was a host-registered top-level statement — missing from DECK_GRAMMAR.md and from Highlight.tish's TOP_KEYS — so every host that wanted it registered its own copy: tish-gba, where it originated, and deck-player. Deckard never implemented it at all.

src/deckfile/Parser.tish already named the consequence:

Without it a host that needs one extra statement — tish-gba's wave <name> <32 hex nibbles> — has to fork the parser, which is how the GBA implementation ended up a separate grammar rather than a subset.

A statement every host implements is a language feature in the wrong place. And the corpus that exists to stop exactly this drift never saw a wave line: conformance parses with no host registrations, so hostStatements is {} in every expected file, while profiles.json carried wave under gba.extensions.topLevel.

What changed

wave is core. Parsed in Parser.tish before the host dispatch, so a host registering wave is now shadowed rather than authoritative (there is a test for that). Removed from the GBA profile's extension list.

New generative form. The hex is a poor way to say what a table is — timbre is harmonic content, so the digits had to be computed elsewhere and pasted in:

wave organ 8beffecbbbbaa9888776554444310014
wave organ harmonics 1 0.5 0.33 0.2

Those two lines are the same sound. a1 is the fundamental, a2 the octave above it, a3 the twelfth; the result is normalized to fill the 4-bit range, so only the ratios matter.

Expanded at parse time, exactly as steps euclid is — and for the same reason. A host reads levels and never learns which spelling produced it, so no host needs changing to keep working. The source form is kept alongside (mode, harmonics, hex) so an emitter can write harmonics back instead of flattening it to hex, which is the trap euclid avoids by retaining hits/len.

Notes for review

  • The clamps in the additive path are removed rather than covered: normalizing by the peak puts every sample in 0..15 by construction, so they were dead code the 100% gate correctly flagged. What is guarded instead is the case that was reachable — a non-finite amplitude, which would have put NaN in the table.
  • The conformance diff on the 12 existing cases is purely additive: one "waves": [] line each, nothing else moved.
  • docs/DECK_EXTENSION.md used wave as its worked example of the host-statement hook. That would now tell hosts to register something the language owns, so the example is a cue statement instead.

Verification

  • npm test — build, 100% line/function/statement coverage, 13 conformance cases, Tish VM suite, JS smoke.
  • npm run test:rust — the regenerated crate passes the same corpus, plus a new facade test asserting the two spellings produce identical levels.
  • packages/player — 15 tests pass, including a new one asserting a harmonics table reaches the audio graph identically to its hex literal.
  • The five wavetables published on chuggie.dev reproduce byte-for-byte from harmonics, so existing documented tables are unchanged.

Downstream PRs follow in schlopai/chuggie (bake), spacedevin/deckard (first-ever wave support), and schlopai/chuggie.dev (docs). This must merge and release first — the others depend on the published deckfile crate and npm packages.

`wave <name> <32 hex nibbles>` was never part of the grammar. It was a
host-registered top-level statement, absent from DECK_GRAMMAR.md and from
Highlight's TOP_KEYS, and every host that needed it registered its own copy —
tish-gba (which it came from), and deck-player. Parser.tish already named the
consequence: the hook exists because, without it, "the GBA implementation ended
up a separate grammar rather than a subset".

A statement every host implements is a language feature living in the wrong
place. The corpus that exists to stop that drift never saw a `wave` line, since
conformance parses with no host registrations.

So `wave` is core now, and profiles.json no longer carries it as a GBA
extension. On top of that, the hex is a poor way to say what a table is: timbre
is harmonic content, and the digits had to be computed somewhere else and pasted
in. `wave <name> harmonics <a1> …` takes amplitudes — fundamental first — and
sums them into the same 32 levels, normalized so only the ratios matter.

Expanded at parse time, exactly as `steps euclid` is, and for the same reason: a
host reads `levels` and never learns which spelling produced it. The source form
is kept alongside so an emitter can write `harmonics` back rather than flattening
it to hex. 013-wave pins that the two spellings resolve to identical levels, in
JS, in the Tish VM, and in the generated Rust crate.

The clamps in the additive path are gone rather than covered: normalizing by the
peak lands every sample in 0..15 by construction. What is guarded instead is the
case that was actually reachable — a non-finite amplitude, which would have put
NaN in the table.
The examples page was eight blocks, every one of them a Game Boy chip song, and
not one of them used a `wave` table — so the page never showed the feature this
branch just adopted, and read as if the language were GBA-only.

Five sections added: wavetables (both spellings, side by side, so you can hear
they are one sound), designing a timbre from harmonics, instrument voices, and
`basicOsc` for when you want the language without the console. Plus mix and fx,
which no example demonstrated at all despite the channel strip applying to every
generator.

Everything stays inside the three generators this player synthesizes faithfully.
Deckard's other thirty parse fine and then get substituted for a plain
oscillator, which on a page where every block has a Play button would be a demo
that lies about what you are hearing. The intro now says so.

test/examples.mjs is what keeps that true: it parses every block on the page and
fails on a substitution or an ignored feature, not just on a parse error.
@spacedevin

Copy link
Copy Markdown
Owner Author

Pushed two follow-ups:

Fixed the conformance case. 013-wave originally used two type wave tracks. The GBA profile test in schlopai/chuggie caught it — the LR35902 has one wave channel — so the case now stays inside the subset it claims to be in. Fixed the case rather than loosening the profile.

Expanded the examples page (docs/EXAMPLES.md, which is what https://spacedevin.github.io/deck/ publishes). It was eight blocks, all Game Boy chip songs, and none of them used a wave table — so the page never showed this feature and read as if the language were GBA-only. Now 14 blocks across five new sections: wavetables (both spellings side by side so you can hear they are one sound), designing a timbre from harmonics, instrument voices, basicOsc, and mix/fx — which nothing demonstrated despite the channel strip applying to every generator.

Everything stays within gameBoyDmg / gbaDirectSound / basicOsc. The other 30 generator ids parse fine but the player substitutes a plain oscillator, and on a page where every block has a Play button that would be a demo that lies. The intro says so now.

test/examples.mjs is new and wired into npm test: it parses every block on the page and fails on a substitution or ignored feature, not just a parse error, so the Play buttons cannot quietly start lying.

@spacedevin
spacedevin merged commit 576be34 into main Aug 27, 2026
5 checks passed
spacedevin added a commit that referenced this pull request Aug 27, 2026
…#17)

The Rust crate job has been posting four `##[error]` annotations on green runs:

  ENOENT: opendir '…/crate/target/package/deckfile-0.1.0/tests/trybuild'
  ENOENT: opendir '…/crate/target/package/deckfile-0.1.0/tests/target'

They come from `Post Cache cargo`, not from any build step. `cargo publish
--dry-run` leaves an extracted copy of the crate under `target/package/`, and
that copy has a `tests/` directory (holding `conformance.rs`). rust-cache prunes
`target/` before saving it, sees `tests/`, and goes looking for the trybuild
layout — `tests/target` and `tests/trybuild` — which this crate has never had.

It only surfaces on a cache MISS, because rust-cache skips the prune entirely on
a hit. That is why it looks sporadic: it appeared on #14, not on #15, and again
on #16, each time a change to the emitted crate moved the cache key.

Nothing should be caching the output of a dry-run publish, so remove it once the
check has served its purpose. The check itself is unchanged.

Worth fixing rather than muting: a green run that reports four errors teaches
everyone to skim past annotations, which is when a real one gets missed.
spacedevin added a commit to spacedevin/deckard that referenced this pull request Aug 27, 2026
* feat: read, play and emit named wavetables

Deckard could not read a `wave` line. The statement originated in tish-gba and
deck-player added its own copy, but the reference host never had one — so a
`.deck` with a named table loaded with "unexpected top-level: wave", and the DMG
wave channel offered three hardcoded shapes and nothing else.

`wave` is core grammar now (spacedevin/deck#16), which resolves both the hex and
`harmonics` spellings to 32 levels before Deckard sees them. What was missing
here is the rest of the path:

- the project model holds the tables, keyed by name
- Apply collects them and points each channel at the table its `wave_shape`
  names; a UI knob turn arrives as a one-track `.deck` fragment through the same
  path, so the binding stays current when the shape is changed by hand
- the DMG wave channel plays a named table in preference to a built-in shape,
  which is the order the GBA bake uses
- the Wave selector lists the song's tables instead of only Saw/Square/Sine

Emit writes the tables back, and writes a `harmonics` line AS harmonics rather
than flattening it to 32 digits. That is the whole reason the parser keeps the
source spelling: a save should not throw away what the author wrote. Only tables
a channel actually names are emitted, or a project would grow a dead `wave` line
every time it loaded a file.

* chore(deps): @spacedevin/deck 1.5.3

The released language package, where `wave` is core grammar — which is what
makes this branch work against a real install rather than a linked build.

All 12 wavetable tests pass against the published package, and the existing
suites are unchanged.
spacedevin added a commit to schlopai/chuggie that referenced this pull request Aug 28, 2026
…20)

* feat: read `wave` from the language, and accept the `harmonics` form

`wave <name> <32 hex nibbles>` originated here, as a fork of the parser, and
survived as a registered dialect extension after the fork was undone. It was
never GBA-specific though — deck-player registered its own copy too — so it has
been adopted into the language proper (spacedevin/deck#16).

That deletes the hex validation and nibble unpacking from this crate. The parser
resolves both spellings to 32 levels, and all that is left here is packing two
levels per byte for wave RAM.

The additive sum lands at bake time, which is the only place it can: the device
is no_std on ARM7TDMI with no FPU and no libm, so there is no sin() on the far
side. Nothing about the ROM changes — still 16 bytes per table, copied verbatim
into WAVE_RAM, so a harmonics table costs exactly what a hex one did.

The corpus digest is unchanged across all 54 decks, and a new test pins the part
that would be invisible otherwise: a harmonics table and the hex literal it
resolves to pack to byte-identical wave RAM.

Blocked on the deckfile 1.5.0 release; the pin is bumped ahead of it, so CI will
not go green until that publishes.

* chore(deps): pin deckfile 1.5.3

The released crate, in place of the version pinned ahead of it. `wave` is core
grammar there, so this is what actually makes the branch build.

All 11 tests pass against the published crate, and the corpus digest across all
54 decks is byte-identical to the pre-change baseline.

* style: satisfy rustfmt and clippy in the wave bake

CI runs `cargo fmt --check` on every crate and `cargo clippy --all-targets -D
warnings` on this one; the wave changes passed neither.

Formatting was mechanical. Clippy found two needless range loops and one real
problem: `assert!(b >> 4 <= 15 && (b & 0x0f) <= 15)` is always true, because a
nibble of a u8 cannot exceed 15. The assertion proved nothing.

Replaced with the invariant actually worth pinning — that packing is lossless
and high-nibble-first. Getting that order wrong still yields 16 plausible bytes,
so nothing downstream would catch it; the ROM would just play a scrambled
waveform.

Tests still 11/11 and the corpus digest is unchanged across all 54 decks.
spacedevin added a commit to spacedevin/deckard that referenced this pull request Aug 31, 2026
* feat: read, play and emit named wavetables

Deckard could not read a `wave` line. The statement originated in tish-gba and
deck-player added its own copy, but the reference host never had one — so a
`.deck` with a named table loaded with "unexpected top-level: wave", and the DMG
wave channel offered three hardcoded shapes and nothing else.

`wave` is core grammar now (spacedevin/deck#16), which resolves both the hex and
`harmonics` spellings to 32 levels before Deckard sees them. What was missing
here is the rest of the path:

- the project model holds the tables, keyed by name
- Apply collects them and points each channel at the table its `wave_shape`
  names; a UI knob turn arrives as a one-track `.deck` fragment through the same
  path, so the binding stays current when the shape is changed by hand
- the DMG wave channel plays a named table in preference to a built-in shape,
  which is the order the GBA bake uses
- the Wave selector lists the song's tables instead of only Saw/Square/Sine

Emit writes the tables back, and writes a `harmonics` line AS harmonics rather
than flattening it to 32 digits. That is the whole reason the parser keeps the
source spelling: a save should not throw away what the author wrote. Only tables
a channel actually names are emitted, or a project would grow a dead `wave` line
every time it loaded a file.

* chore(deps): @spacedevin/deck 1.5.3

The released language package, where `wave` is core grammar — which is what
makes this branch work against a real install rather than a linked build.

All 12 wavetable tests pass against the published package, and the existing
suites are unchanged.
spacedevin added a commit to schlopai/chuggie that referenced this pull request Sep 1, 2026
… form (#27)

* feat: read `wave` from the language, and accept the `harmonics` form

`wave <name> <32 hex nibbles>` originated here, as a fork of the parser, and
survived as a registered dialect extension after the fork was undone. It was
never GBA-specific though — deck-player registered its own copy too — so it has
been adopted into the language proper (spacedevin/deck#16).

That deletes the hex validation and nibble unpacking from this crate. The parser
resolves both spellings to 32 levels, and all that is left here is packing two
levels per byte for wave RAM.

The additive sum lands at bake time, which is the only place it can: the device
is no_std on ARM7TDMI with no FPU and no libm, so there is no sin() on the far
side. Nothing about the ROM changes — still 16 bytes per table, copied verbatim
into WAVE_RAM, so a harmonics table costs exactly what a hex one did.

The corpus digest is unchanged across all 54 decks, and a new test pins the part
that would be invisible otherwise: a harmonics table and the hex literal it
resolves to pack to byte-identical wave RAM.

Blocked on the deckfile 1.5.0 release; the pin is bumped ahead of it, so CI will
not go green until that publishes.

* chore(deps): pin deckfile 1.5.3

The released crate, in place of the version pinned ahead of it. `wave` is core
grammar there, so this is what actually makes the branch build.

All 11 tests pass against the published crate, and the corpus digest across all
54 decks is byte-identical to the pre-change baseline.

* style: satisfy rustfmt and clippy in the wave bake

CI runs `cargo fmt --check` on every crate and `cargo clippy --all-targets -D
warnings` on this one; the wave changes passed neither.

Formatting was mechanical. Clippy found two needless range loops and one real
problem: `assert!(b >> 4 <= 15 && (b & 0x0f) <= 15)` is always true, because a
nibble of a u8 cannot exceed 15. The assertion proved nothing.

Replaced with the invariant actually worth pinning — that packing is lossless
and high-nibble-first. Getting that order wrong still yields 16 plausible bytes,
so nothing downstream would catch it; the ROM would just play a scrambled
waveform.

Tests still 11/11 and the corpus digest is unchanged across all 54 decks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant